<?php

/* vim: set expandtab tabstop=4 shiftwidth=4 softtabstop=4: */

/**
 * This file contains the following classes:
 *
 * - Domain_Object
 * - Domain_Object_Exception
 * - Domain_Object_FindMismatchException
 * - Domain_Object_NotFoundException
 * - Domain_Object_FindException
 * - Domain_Object_SaveException
 * - Domain_Object_InvalidPropertyException
 *
 * PHP version 5
 *
 * This file is part of Majic - A rapid-development web application framework
 * for PHP 5.
 *
 * Majic is free software: you can redistribute it and/or modify it under
 * the terms of the GNU Lesser General Public License as published by the Free
 * Software Foundation, either version 3 of the License, or (at your option)
 * any later version.
 *
 * Majic is distributed in the hope that it will be useful, but WITHOUT ANY
 * WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS
 * FOR A PARTICULAR PURPOSE. See the GNU Lesser General Public License for more
 * details.
 *
 * You should have received a copy of the GNU Lesser General Public License
 * along with Majic. If not, see <http://www.gnu.org/licenses/>.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 * @copyright   2007-2008 Michael J. I. Jackson
 * @license     http://www.gnu.org/licenses/lgpl-3.0.txt GNU LGPL 3.0
 * @version     SVN: $Id: Object.php 34 2008-07-19 04:50:53Z mjijackson $
 */

use_helper('string'); // for camel2uscore()

/**
 * The base class that all domain objects should inherit from. These objects
 * are smart in that they can use either direct field naming or dynamic method
 * calling to access object properties. Likewise, object properties can be set
 * either with direct field naming or using dynamic set* methods.
 *
 * <code>
 * $obj->first_name = 'Joe';
 * $obj->setFirstName('Joe');
 * $obj->first_name;        // Joe
 * $obj->getFirstName();    // Joe
 * </code>
 *
 * Domain objects also keep track of which properties have changed since they
 * were last saved, so they can determine whether or not they need saving.
 * This will also help with logging changes that are made to objects in the
 * database.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
abstract class Domain_Object
{

    /**
     * This object's unique identifier. May be an array of values if this
     * object has a shared {@link $primary_key primary key}.
     *
     * @var     mixed
     * @access  protected
     */
    protected $id;

    /**
     * An array of properties of this object that have been modified since the
     * last {@link save() save}. The array is associative with the property name
     * as the key and an array of from => to values as the value.
     *
     * <code>
     * $dirty = array(
     *     'last_name'     => array(
     *         'from'      => 'Michaels',
     *         'to'        => 'Brown'
     *     )
     * );
     * </code>
     *
     * When the object is saved, this array should be automatically cleared.
     *
     * @var     array
     * @access  protected
     */
    protected $dirty = array();

    /**
     * Keeps track of whether or not this object is already in the database.
     *
     * @var     bool
     * @access  protected
     */
    protected $already_in_db = false;

    /**
     * Stores this object's properties.
     *
     * @var     array
     * @access  protected
     */
    protected $props = array();

    /**
     * Constructor. Sets all of the initial properties on this object. The
     * $props parameter should be an associative array of key => value pairs to
     * use as this object's properties.
     *
     * If the object is already in the database, its entire primary key should
     * be contained in the $props array. Otherwise, the props array should not
     * already contain the primary key. Instead, it will be automatically set
     * the first time the object is actually {@link save() saved}.
     *
     * @param   mixed   $props              The properties to use for this
     *                                      object
     * @param   bool    $already_in_db      True if the object is already stored
     *                                      in the database, false otherwise
     * @access  public
     */
    public function __construct($props = array(), $already_in_db = false)
    {
        if ($already_in_db) {
            $this->already_in_db = true;

            // set id first, primary key may be array of values (shared)
            $key = $this->getPrimaryKey();
            if (is_array($key)) {
                foreach ($key as $k) {
                    $id[] = (string) $props[$k];
                    unset($props[$k]);
                }
            } else {
                $id = (string) $props[$key];
                unset($props[$key]);
            }
            $this->setId($id);
        }

        foreach ($props as $prop => $value) {
            $this->set($prop, $value);
        }

        if ($already_in_db) {
            $this->markClean();
        }
    }

    /**
     * Gets the unique identifier associated with this object. If the object
     * class has a shared primary key, this value will be the values of the
     * object's primary key concatenated to form a {@link formatId() string}.
     *
     * @return  string          This object's unique identifier
     * @access  public
     */
    public function getId()
    {
        return call_user_func(array(get_class($this), 'formatId'), $this->id);
    }

    /**
     * Sets the unique identifier of this object.
     *
     * @param   mixed   $id     The value(s) of the object's primary key
     * @return  void
     * @access  protected
     */
    protected function setId($id)
    {
        $this->id = $id;
    }

    /**
     * Converts a unique identifier to a string value. This function is only
     * necessary for object classes that have a shared primary key.
     *
     * @param   mixed   $id     The value(s) of the object's primary key
     * @return  string          The unique identifier to use for this object
     * @access  protected
     * @static
     */
    protected static function formatId($id)
    {
        if (is_array($id)) {
            $id = implode('.', $id);
        }

        return $id;
    }

    /**
     * Returns the name(s) of the primary key(s) for this object class.
     *
     * @return  mixed       This object class' primary key
     * @access  public
     * @static
     */
    public static function primaryKey()
    {
        return 'id';
    }

    /**
     * Returns the name(s) of this object's primary key.
     *
     * @return  mixed       This object's primary key
     * @access  public
     */
    public function getPrimaryKey()
    {
        return call_user_func(array(get_class($this), 'primaryKey'));
    }

    /**
     * Finds the object of the given domain class with the given id.
     * Checks the domain map for the specified object. If it is not found,
     * delegates the find function to the object class' doFind() method and
     * stores the resulting object in the domain map before returning it.
     *
     * @param   string          $id                 The object id
     * @return  Domain_Object                       The domain object
     * @throws  Domain_Object_FindMismatchException If the number of values in
     *                                              the given id does not match
     *                                              the number of values in this
     *                                              domain class' primary key
     * @throws  Domain_Object_FindException         If there is some exception
     *                                              thrown from the data
     *                                              retrieval layer
     * @throws  Domain_Object_NotFoundException     If the domain object with
     *                                              the given id cannot be
     *                                              found
     * @access  public
     * @static
     */
    public static function find($obj_cls, $id)
    {
        try {
            $p_key = call_user_func(array($obj_cls, 'primaryKey'));
            if ($objs = self::findBy($obj_cls, $p_key, $id)) {
                return $objs[0];
            }
        } catch (Exception $e) {
            throw new Domain_Object_FindException($obj_cls, $e);
        }

        $f_id = call_user_func(array($obj_cls, 'formatId'), $id);
        throw new Domain_Object_NotFoundException($obj_cls, $f_id);
    }

    /**
     * Finds all objects of the given domain object class where the given
     * propert(ies) match the given value(s).
     *
     * NOTE: This function checks the domain map to resolve duplicates before
     * returning any objects. When searching by primary key, this process can
     * be very efficient because it is able to check the domain map BEFORE
     * making any call to the database.
     *
     * @param   string  $obj_cls            The domain object class
     * @param   mixed   $key                The property name(s)
     * @param   mixed   $value              The value(s) to match
     * @return  array                       All objects that match the given
     *                                      criteria
     * @throws  Domain_Object_FindMismatchException If the number of properties
     *                                              given does not match the
     *                                              number of values
     * @throws  Domain_Object_FindException         If there is some exception
     *                                              thrown from the data layer
     * @access  public
     * @static
     */
    public static function findBy($obj_cls, $key, $value)
    {
        // if prop and value are both arrays but are not the same length
        if (!(!is_array($key) || is_array($value) && count($key) == count($value))) {
            throw new Domain_Object_FindMismatchException($key, $value);
        }

        // short circuit, check the domain map when searching by id
        $p_key = call_user_func(array($obj_cls, 'primaryKey'));
        if ($key == $p_key) {
            $f_id = call_user_func(array($obj_cls, 'formatId'), $value);
            if ($obj = Domain::find($obj_cls, $f_id)) {
                return array($obj);
            }
        }

        // get an array of objects that match
        if (is_array($key)) {
            $where = array_combine($key, $value);
        } else {
            $where = array();
            $where[$key] = $value;
        }
        try {
            $objs = call_user_func(array($obj_cls, 'doFindBy'), $where, $obj_cls);
        } catch (Exception $e) {
            throw new Domain_Object_FindException($obj_cls, $e);
        }

        // replace objects with those already in the domain map, or add them to
        // the map if they're not already there
        foreach ($objs as &$obj) {
            if ($cached = Domain::find($obj_cls, $obj->getId())) {
                $obj = $cached;
            } else {
                Domain::add($obj);
            }
        }

        return $objs;
    }

    /**
     * Reads all objects of this class' type from the database that match the
     * given parameters. Parameters here will be given in an array of field =>
     * value pairs.
     *
     * @param   array   $where              The parameters to match
     * @param   string  $obj_cls            The domain object class name
     * @return  array                       All objects that match the given
     *                                      parameters
     * @access  public
     * @static
     * @abstract
     */
    abstract public static function doFindBy($where, $obj_cls);

    /**
     * Makes sure that this object is dirty (actually needs saving) before
     * delegating the save function to the object's own doSave() method. Upon
     * a successful save, all listeners are notified and the object's
     * internal properties are updated accordingly.
     *
     * @return  void
     * @throws  Domain_Object_SaveException         If there is some exception
     *                                              thrown from the data layer
     * @access  public
     */
    public function save()
    {
        if (!$this->isDirty()) {
            return;
        }

        try {
            if ($this->isAlreadyInDb()) {
                $p_key = $this->getPrimaryKey();
                if (is_array($p_key)) {
                    $where = array_combine($p_key, $this->id); // use raw id
                } else {
                    $where = array();
                    $where[$p_key] = $this->id; // use raw id
                }
                $this->doSave($where);
            } else {
                $id = $this->doSave();
                // update object id
                $this->setId($id);
                // mark already in db
                $this->markAlreadyInDb();
            }
        } catch (Exception $e) {
            throw new Domain_Object_SaveException(get_class($this), $this->getId(), $e);
        }

        // notify save listeners before cleaning so they can have access to the
        // list of dirty properties
        $this->notifySave();
        $this->markClean();
    }

    /**
     * Takes care of the particulars of saving this object to the database. If
     * the object is already in the database, the $where parameter will be an
     * array of key => value pair(s) of this object's primary key(s) and
     * value(s). Otherwise, this parameter will be NULL and the function should
     * return the newly inserted record's id on success. Note that for object
     * classes with a shared primary key, the return value will be an array.
     *
     * @param   array   $where              For UPDATEs, the key => value pairs
     *                                      to use in the WHERE clause
     * @return  mixed                       For INSERTs, the newly generated
     *                                      object id
     * @access  public
     * @abstract
     */
    abstract public function doSave($where = null);

    /**
     * Checks to see if the given property is defined on this object.
     *
     * @param   string  $key        The object property name
     * @return  bool                True if it exists, false otherwise
     * @access  public
     */
    public function hasProperty($key)
    {
        return array_key_exists($key, $this->props);
    }

    /**
     * Gets an array of property names that are defined on this object. Can be
     * helpful in debugging when it is not clear if this object contains the
     * specified property or not.
     *
     * @return  array               A list of this object's property names
     * @access  public
     */
    public function getPropertyNames()
    {
        return array_keys($this->props);
    }

    /**
     * Catches all get/set method calls on this object and performs the
     * appropriate get/set operation.
     *
     * @param   string  $method     The method that was called
     * @param   array   $args       The arguments that were passed to the method
     * @return  mixed               The result of the get/set operation
     * @access  public
     */
    public function __call($method, $args)
    {
        preg_match('/(get|set)(.*)/', $method, $match);

        if ($match) {
            $key = camel2uscore($match[2]);

            if ($match[1] == 'get') {
                return $this->get($key);
            } else {
                $this->set($key, $args[0]);
                return;
            }
        }

        // trigger undefined method error
        trigger_error(sprintf('Call to undefined method %s::%s()',
            get_class($this), $method), E_USER_ERROR);
    }

    /**
     * Gets the value of the given property for this object. Provided for
     * convenience in syntax.
     *
     * @param   string  $key        The name of the property
     * @return  mixed               The property's value
     * @access  public
     */
    public function __get($key)
    {
        return $this->get($key);
    }

    /**
     * Sets the value of the given property for this object. Provided for
     * convenience in syntax.
     *
     * @param   string  $key        The name of the property to set
     * @param   mixed   $value      The value to use
     * @return  void
     * @access  public
     */
    public function __set($key, $value)
    {
        $this->set($key, $value);
    }

    /**
     * Gets the the value of the given property for this object.
     *
     * @param   string  $key        The name of the property
     * @return  mixed               The property's value
     * @access  public
     */
    public function get($key)
    {
        try {
            $this->assertValidKey($key);
        } catch (Domain_Object_InvalidPropertyException $e) {
            // catch convenience properties
            $method = 'get' . $key;
            if (method_exists($this, $method)) {
                return $this->{$method}();
            }

            throw $e;
        }

        return $this->props[$key];
    }

    /**
     * Sets the value of the given property for this object.
     *
     * @param   string  $key        The name of the property to set
     * @param   mixed   $value      The value to use
     * @return  void
     * @access  public
     */
    public function set($key, $value)
    {
        try {
            $this->assertValidKey($key);
        } catch (Domain_Object_InvalidPropertyException $e) {
            // catch convenience properties
            $method = 'set' . $key;
            if (method_exists($this, $method)) {
                $this->{$method}($value);
                return;
            }

            throw $e;
        }

        // are they already the same?
        if ($this->props[$key] == $value) {
            return;
        }

        $from = $this->props[$key];
        $this->props[$key] = $value;

        // set the dirty variable
        if (isset($this->dirty[$key])) {
            $this->dirty[$key]['to'] = $value;
        } else {
            $this->dirty[$key] = array(
                'from'  => $from,
                'to'    => $value
            );
        }
    }

    /**
     * Gets an associative array of this object's altered properties. Useful for
     * logging changes.
     *
     * @return  array           This object's "dirty" fields
     * @access  public
     * @see     RNT_Object::$dirty
     */
    public function getDirtyProperties()
    {
        return $this->dirty;
    }

    /**
     * Tells if this object (or a single property of this object) is "dirty"
     * (i.e. has been modified since being created).
     *
     * @param   string  $key    (optional) The key to check
     * @return  bool            True if the object is dirty, false otherwise
     * @access  public
     */
    public function isDirty($key = null)
    {
        if ($key == null) {
            return count($this->dirty) > 0;
        } else {
            return isset($this->dirty[$key]);
        }
    }

    /**
     * Marks this object as clean meaning that any changes that have occured on
     * this object will not be propagated to the database on save.
     *
     * @return  void
     * @access  public
     */
    public function markClean()
    {
        $this->dirty = array();
    }

    /**
     * Tells if this object is already in the database.
     *
     * @return  bool
     * @access  public
     */
    public function isAlreadyInDb()
    {
        return $this->already_in_db;
    }

    /**
     * Marks this object as already being in the database. Automatically called
     * when this object is saved.
     *
     * @return  void
     * @access  public
     */
    public function markAlreadyInDb()
    {
        $this->already_in_db = true;
    }

    /**
     * Asserts that the provided key is actually a property of this object.
     * We need to perform these assertions so that we can be notified when we
     * use invalid properties instead of just assuming the are not valid.
     *
     * @param   string  $key        The object property name
     * @return  void
     * @throws  Domain_Object_InvalidPropertyException  If the given property is
     *                                                  not defined on this
     *                                                  object
     * @access  private
     */
    private function assertValidKey($key)
    {
        if (!$this->hasProperty($key)) {
            throw new Domain_Object_InvalidPropertyException($key, get_class($this));
        }
    }

    /**
     * Notifies this object's class that it is being saved.
     *
     * @return  void
     * @access  protected
     */
    protected function notifySave()
    {
        Domain::fireOnSaveListeners($this);
    }

}

/**
 * The base class for all Domain_Object exceptions.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_Exception extends Domain_Exception {}

/**
 * Domain object find mismatch exception. Thrown when {@link Domain_Object::findBy() findBy()}
 * is called with property and value arrays that do not match in length.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_FindMismatchException extends Domain_Object_Exception
{

    /**
     * Constructor.
     *
     * @param   array       $prop       The properties that were given
     * @param   array       $value      The values that were given
     * @access  public
     */
    public function __construct($prop, $value)
    {
        $pc = count($prop);
        $vc = count($value);
        parent::__construct("Number of find properties ($pc) does not match number of values ($vc)");
    }

}

/**
 * Domain object not found exception. Thrown when an object with the given id
 * and class cannot be found in the database.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_NotFoundException extends Domain_Object_Exception
{

    /**
     * Constructor.
     *
     * @param   string      $obj_cls        The object class name
     * @param   string      $id             The object id
     * @access  public
     */
    public function __construct($obj_cls, $id)
    {
        parent::__construct("$obj_cls with id $id was not found");
    }

}

/**
 * Domain object find exception. Thrown when an exception bubbles up from some
 * underlying layer when finding domain objects.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_FindException extends Domain_Object_Exception
{

    /**
     * Constructor.
     *
     * @param   string      $obj_cls        The object class name
     * @param   Exception   $cause          The exception that triggered this
     *                                      exception
     * @access  public
     */
    public function __construct($obj_cls, $cause)
    {
        parent::__construct("Error finding $obj_cls object(s)", $cause);
    }

}

/**
 * Domain object save exception. Thrown when there is an error saving the object
 * with the given id to the database.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_SaveException extends Domain_Object_Exception
{

    /**
     * Constructor.
     *
     * @param   string      $obj_cls        The object class
     * @param   string      $id             The object's id
     * @param   Exception   $cause          The exception that triggered this
     *                                      exception
     * @access  public
     */
    public function __construct($obj_cls, $id, $cause)
    {
        parent::__construct("Error saving $obj_cls with id $id", $cause);
    }

}

/**
 * Domain object invalid property exception. Thrown when the property that is
 * being accessed is not defined for this object.
 *
 * @package     Domain
 * @author      Michael J. I. Jackson <mjijackson@gmail.com>
 */
class Domain_Object_InvalidPropertyException extends Domain_Object_Exception
{

    /**
     * Constructor.
     *
     * @param   string  $prop       The property name
     * @param   string  $obj_cls    The name of the object class
     * @access  public
     */
    public function __construct($prop, $obj_cls)
    {
        parent::__construct("Invalid property \"$prop\" on object of type $obj_cls");
    }

}

?>
